Jetpack Compose 到底优秀在哪里?| 开发者说·DTalk
The following article is from 字节数组 Author 业志陈
原生 View 体系下,我们一直强调要减少布局的嵌套层次,那这么做的意义是什么呢;
Jetpack Compose 的布局模型; Jetpack Compose 如何实现自定义布局; Jetpack Compose 是如何避免多次测量的; Jetpack Compose 固有特性测量的作用,以及如何进行适配。
减少布局嵌套的意义
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:orientation="vertical"
android:layout_height="match_parent">
<TextView
android:id="@+id/textView1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:padding="20dp"
android:text="Jetpack Compose" />
<TextView
android:id="@+id/textView2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="业志陈" />
<TextView
android:id="@+id/textView3"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="公众号:字节数组" />
</FrameLayout>
该布局有以下特点:
FrameLayout 的宽度是 wrap_content,即打算宽度由其子项来决定,子项的最大宽度是多少,FrameLayout 的宽度即是多少; textView2 和 textView3 的宽度是 match_parent,即希望宽度占满整个 FrameLayout,FrameLayout 的宽度是多少,这两个 TextView 的宽度即是多少。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int count = getChildCount();
//layout_width 或 layout_height 是否设置了 wrap_content
final boolean measureMatchParentChildren =
MeasureSpec.getMode(widthMeasureSpec) != MeasureSpec.EXACTLY ||
MeasureSpec.getMode(heightMeasureSpec) != MeasureSpec.EXACTLY;
mMatchParentChildren.clear();
int maxHeight = 0;
int maxWidth = 0;
int childState = 0;
for (int i = 0; i < count; i++) {
final View child = getChildAt(i);
if (mMeasureAllChildren || child.getVisibility() != GONE) {
//第一次测量子项的宽高
measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, 0);
final LayoutParams lp = (LayoutParams) child.getLayoutParams();
maxWidth = Math.max(maxWidth,
child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin);
maxHeight = Math.max(maxHeight,
child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin);
childState = combineMeasuredStates(childState, child.getMeasuredState());
if (measureMatchParentChildren) {
if (lp.width == LayoutParams.MATCH_PARENT ||
lp.height == LayoutParams.MATCH_PARENT) {
//将使用了 match_parent 的子项保存起来
mMatchParentChildren.add(child);
}
}
}
}
···
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
···
if (count > 1) {
for (int i = 0; i < count; i++) {
final View child = mMatchParentChildren.get(i);
final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
final int childWidthMeasureSpec;
if (lp.width == LayoutParams.MATCH_PARENT) {
final int width = Math.max(0, getMeasuredWidth()
- getPaddingLeftWithForeground() - getPaddingRightWithForeground()
- lp.leftMargin - lp.rightMargin);
childWidthMeasureSpec = MeasureSpec.makeMeasureSpec(
width, MeasureSpec.EXACTLY);
} else {
childWidthMeasureSpec = getChildMeasureSpec(widthMeasureSpec,
getPaddingLeftWithForeground() + getPaddingRightWithForeground() +
lp.leftMargin + lp.rightMargin,
lp.width);
}
···
//第二次测量子项的宽高
child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}
}
}
所以说,在这种简单的嵌套结构中,父布局和子项之间的宽度是相互影响并通过双方来一起确定的,导致了需要先后执行多次 measure 操作才能完成布局要求: FrameLayout 一次,textView1 一次,textView2 和 textView3 各两次,总计六次。
即使使用的不是 FrameLayout,改为 LinearLayout 一样会有这个问题。此外,虽然我们一般也不会用 wrap_content 来嵌套 match_parent,但如果搭配使用 LinearLayout 和 layout_weight 的话,测量次数也不止一次;
如果将 textView2 和 textView3 替换为其它 ViewGroup 的话,将连锁导致 ViewGroup 内嵌的其它子项也要跟着一起重新测量;
如果该布局是用于 Activity 的话,由于 ViewRootImpl 的 performTraversals() 会在 Activity 启动时被调用两次 (API Leave 31),因此 FrameLayout 需要测量两次,连锁导致 textView1 测量两次,textView2 和 textView3 各测量四次,总计十二次。
布局模型
举个例子,以下的 SearchResult() 函数会生成相对应的界面树:
@Composable
fun SearchResult(...) {
Row(...) {
Image(...)
Column(...) {
Text(...)
Text(..)
}
}
}
SearchResult
Row
Image
Column
Text
Text
在 SearchResult 示例中,界面树布局遵循以下顺序:
系统要求根节点 Row 对自身进行测量; 根节点 Row 要求其第一个子节点 (即 Image) 进行测量; Image 是一个叶节点 (也就是说,它没有子节点),因此该节点会报告尺寸并返回放置指令; 根节点 Row 要求其第二个子节点 (即 Column) 进行测量; 节点 Column 要求其第一个子节点 Text 进行测量; 由于第一个节点 Text 是叶节点,因此该节点会报告尺寸并返回放置指令; 节点 Column 要求其第二个子节点 Text 进行测量; 由于第二个节点 Text 是叶节点,因此该节点会报告尺寸并返回放置指令; 现在,节点 Column 已测量其子节点,并已确定其子节点的尺寸和放置位置,接下来它可以确定自己的尺寸和放置位置了; 现在,根节点 Row 已测量其子节点,并已确定其子节点的尺寸和放置位置,接下来它可以确定自己的尺寸和放置位置了。
自定义布局
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ComposeCustomLayoutTheme {
Surface(
modifier = Modifier
.fillMaxSize()
.wrapContentSize(align = Alignment.Center),
color = MaterialTheme.colors.background
) {
CustomLayout()
}
}
}
}
}
@Composable
private fun CustomLayout() {
CustomLayout(
modifier = Modifier
.background(color = Color.Yellow)
) {
Spacer(
modifier = Modifier
.background(color = Color.Green)
.size(size = 40.dp)
)
Spacer(
modifier = Modifier
.background(color = Color.Cyan)
.size(size = 40.dp)
)
Spacer(
modifier = Modifier
.background(color = Color.Magenta)
.size(size = 40.dp)
)
Spacer(
modifier = Modifier
.background(color = Color.Red)
.size(size = 40.dp)
)
}
}
@Composable
inline fun Layout(
content: @Composable () -> Unit,
modifier: Modifier = Modifier,
measurePolicy: MeasurePolicy
)
content。布局组件所包含的所有子项; modifier。对布局组件声明的 Modifier,可以通过该值来声明自定义布局的尺寸; measurePolicy。测量策略,布局组件包含的所有子项的尺寸和位置都是在当中进行设置。
fun interface MeasurePolicy {
fun MeasureScope.measure(measurables: List<Measurable>, constraints: Constraints): MeasureResult
}
measurables。每一个 Measurable 都对应自定义布局中的一个子项,同时也是子项的测量句柄,通过调用其内部的 measure(constraints: Constraints) 方法来对子项进行测量; constraints。自定义的布局组件的父级对其的约束条件。
measure 方法传入的 constraints 参数,代表的是 CustomLayout 的上一级对其的布局约束条件,包含的约束条件有 minWidth、maxWidth、minHeight、maxHeight,由于这里不想让上一级布局设定的 minWidth 和 minHeight 对子项产生影响,因此要将其重置为 0,或者说根据实际需要来构建一个新的 Constraints; CustomLayout 的整体宽高是通过累加内部所有子项的宽高而得来的,因此要先测量出每个子项的宽高后才能确定 CustomLayout 自身的宽高; 当确定了 CustomLayout 的宽高大小后,通过 MeasureScope.layout 方法来传递宽高信息; MeasureScope.layout 方法会提供一个 Placeable.PlacementScope 作用域,在这个作用域内部通过 placeable.place、placeable.placeRelative 等方法来放置每一个子项,即在这里计算每个子项在坐标系中的位置。
@Composable
fun CustomLayout(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Layout(
content = content,
modifier = modifier,
measurePolicy = object : MeasurePolicy {
override fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
): MeasureResult {
if (measurables.isEmpty()) {
return layout(
constraints.minWidth,
constraints.minHeight
) {}
}
//第一步
val contentConstraints = constraints.copy(minWidth = 0, minHeight = 0)
val placeables = arrayOfNulls<Placeable>(measurables.size)
var layoutWidth = 0
var layoutHeight = 0
//第二步,测量所有子项,累加所有子项的宽高值
measurables.forEachIndexed { index, measurable ->
val placeable = measurable.measure(contentConstraints)
placeables[index] = placeable
layoutWidth += placeable.width
layoutHeight += placeable.height
}
//第三步,传递布局自身所占据的宽高
return layout(layoutWidth, layoutHeight) {
var top = 0
var left = 0
//第四步,计算每个子项应该放置的坐标值
placeables.forEach { placeable ->
if (placeable != null) {
placeable.place(position = IntOffset(x = left, y = top))
top += placeable.height
left += placeable.width
}
}
}
}
}
)
}
固有特性测量
Divider(
color = Color.Black,
modifier = Modifier
.width(10.dp)
.fillMaxHeight()
)
还是以 CustomLayout 为例,先来看下固有特性测量的使用方式,这里主要就做了两点改动:
CustomLayout 通过 IntrinsicSize.Min 来声明自己期望按最小固有高度来进行显示,相对应的还有一个 IntrinsicSize.Max; 为 CustomLayout 新增了一个扩展函数 matchParentHeight(),交给 Divider 使用,用于表明该子项想要直接占满父布局高度。
@Composable
private fun CustomLayout() {
CustomLayout(
modifier = Modifier
.height(intrinsicSize = IntrinsicSize.Min)
.background(color = Color.Yellow)
) {
Spacer(
modifier = Modifier
.background(color = Color.Green)
.size(size = 40.dp)
)
Spacer(
modifier = Modifier
.background(color = Color.Cyan)
.size(size = 40.dp)
)
Divider(
modifier = Modifier
.width(width = 6.dp)
.matchParentHeight(),
color = Color.Gray
)
Spacer(
modifier = Modifier
.background(color = Color.Magenta)
.size(size = 40.dp)
)
Spacer(
modifier = Modifier
.background(color = Color.Red)
.size(size = 40.dp)
)
}
}
fun interface MeasurePolicy {
fun MeasureScope.measure(measurables: List<Measurable>, constraints: Constraints): MeasureResult
fun IntrinsicMeasureScope.minIntrinsicWidth(measurables: List<IntrinsicMeasurable>, height: Int): Int
fun IntrinsicMeasureScope.minIntrinsicHeight(measurables: List<IntrinsicMeasurable>, width: Int): Int
fun IntrinsicMeasureScope.maxIntrinsicWidth(measurables: List<IntrinsicMeasurable>, height: Int): Int
fun IntrinsicMeasureScope.maxIntrinsicHeight(measurables: List<IntrinsicMeasurable>, width: Int): Int
}
override fun IntrinsicMeasureScope.minIntrinsicHeight(
measurables: List<IntrinsicMeasurable>,
width: Int
): Int {
var maxHeight = 0
measurables.forEach {
if (!it.matchParentHeight()) {
maxHeight += it.minIntrinsicHeight(width)
}
}
return maxHeight
}
重写这些方法后,就会影响 CustomLayout 获取到的 Constraints 中 minWidth、maxWidth、minHeight、maxHeight 等值的大小,使得 CustomLayout 能够感知到调用方允许自己显示的最小尺寸和最大尺寸。
在适配固有特性测量之前,由于 CustomLayout 是根布局,所以 Constraints 对应的最大尺寸即屏幕宽高,类似于 Constraints(minWidth = 0, maxWidth = 1080, minHeight = 0, maxHeight = 1776),因此 Divider 采用了 fillMaxHeight() 修饰后就会直接撑满整个屏幕高度。
在适配固有特性测量之后,由于 CustomLayout 使用了 IntrinsicSize.Min 来进行修饰,在语义上就是希望 CustomLayout 能够按照最小高度来进行显示,因此 Constraints 对应的最小和最大高度就变成了 minIntrinsicHeight 方法的返回值,类似于 Constraints(minWidth = 0, maxWidth = 1080, minHeight = 480, maxHeight = 480),此时 Divider 的高度就会直接固定为 480 px 了,而不会越界。
所以说,在适配了固有特性测量机制后,四个 Intrinsic 方法相当于是在正式开始 measure 之前进行的一次粗略测量,一次性计算出能接受的尺寸范围,CustomLayout 就无需在 measure 阶段来多次测量子项了,而是改为依靠 Intrinsic 方法来影响子项的测量结果,从而避免了多次测量。
适配固有特性测量
为了能够识别出哪一个子项希望占满布局高度,就需要将这种 "期望" 传递给 CustomLayout,也即子项需要能够传参给到父布局。Jetpack Compose 通过 ParentDataModifier 来实现参数传递,我们在使用 Column 时声明的 weight 参数也是通过 ParentDataModifier 来传递的,IntrinsicMeasurable 接口内部就包含一个 Any? 类型的 parentData 参数。
CustomLayout 通过声明自己专属的 CustomLayoutParentData 类来作为参数载体,并通过扩展函数 matchParentHeight() 来传递参数值。
@LayoutScopeMarker
@Immutable
interface CustomLayoutScope {
@Stable
fun Modifier.matchParentHeight(): Modifier
}
private object CustomLayoutScopeInstance : CustomLayoutScope {
override fun Modifier.matchParentHeight(): Modifier {
return this.then(
LayoutMatchParentHeightImpl(
matchParentHeight = true,
inspectorInfo = debugInspectorInfo {
name = "matchParentHeight"
value = true
properties["matchParentHeight"] = true
}
)
)
}
}
internal data class CustomLayoutParentData(
val matchParentHeight: Boolean = false
)
internal class LayoutMatchParentHeightImpl(
val matchParentHeight: Boolean,
inspectorInfo: InspectorInfo.() -> Unit
) : ParentDataModifier, InspectorValueInfo(inspectorInfo) {
override fun Density.modifyParentData(parentData: Any?): Any {
return (parentData as? CustomLayoutParentData)
?: CustomLayoutParentData(matchParentHeight = matchParentHeight)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as LayoutMatchParentHeightImpl
if (matchParentHeight != other.matchParentHeight) return false
return true
}
override fun hashCode(): Int {
return matchParentHeight.hashCode()
}
override fun toString(): String {
return "LayoutMatchParentHeightImpl(matchParentHeight=$matchParentHeight)"
}
}
private fun IntrinsicMeasurable.matchParentHeight(): Boolean {
return (parentData as? CustomLayoutParentData)?.matchParentHeight ?: false
}
为子项提供一个专属的特定布局作用域 CustomLayoutScope,确保 matchParentHeight() 方法只有 CustomLayout 的子项才能使用;
Divider 会影响 CustomLayout 占据的整体宽度,但不会影响 CustomLayout 的整体高度,因此在进行 measure 时就要分情况来判断是否需要累加 layoutHeight;
在计算子项的坐标值时,除了 Divider 的 Y 坐标需要固定为 0 外,其它子项照旧。
@Composable
fun CustomLayout(
modifier: Modifier = Modifier,
content: @Composable CustomLayoutScope.() -> Unit
) {
Layout(
content = { CustomLayoutScopeInstance.content() },
modifier = modifier,
measurePolicy = object : MeasurePolicy {
private fun IntrinsicMeasurable.matchParentHeight(): Boolean {
return (parentData as? CustomLayoutParentData)?.matchParentHeight ?: false
}
override fun IntrinsicMeasureScope.minIntrinsicHeight(
measurables: List<IntrinsicMeasurable>,
width: Int
): Int {
var maxHeight = 0
measurables.forEach {
if (!it.matchParentHeight()) {
maxHeight += it.minIntrinsicHeight(width)
}
}
return maxHeight
}
override fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
): MeasureResult {
if (measurables.isEmpty()) {
return layout(
constraints.minWidth,
constraints.minHeight
) {}
}
val contentConstraints = constraints.copy(minWidth = 0, minHeight = 0)
val dividerConstraints = constraints.copy(minWidth = 0)
val placeables = arrayOfNulls<Placeable>(measurables.size)
val matchParentHeightChildren = mutableListOf<Placeable>()
var layoutWidth = 0
var layoutHeight = 0
measurables.forEachIndexed { index, measurable ->
val placeable = if (measurable.matchParentHeight()) {
measurable.measure(dividerConstraints).apply {
layoutWidth += width
matchParentHeightChildren.add(this)
}
} else {
measurable.measure(contentConstraints).apply {
layoutWidth += width
layoutHeight += height
}
}
placeables[index] = placeable
}
return layout(layoutWidth, layoutHeight) {
var top = 0
var left = 0
placeables.forEach { placeable ->
placeable as Placeable
if (matchParentHeightChildren.contains(placeable)) {
placeable.place(position = IntOffset(x = left, y = 0))
} else {
placeable.place(position = IntOffset(x = left, y = top))
top += placeable.height
}
left += placeable.width
}
}
}
}
)
}
最终 CustomLayout 就可以正常显示 Divider 了。
结尾
Jetpack Compose 除了通过固有特性测量机制避免多次测量外,也少了将 XML 文件反射实例化为 View 的步骤,减少了 I/O 操作,这也是 Jetpack Compose 的一个性能优势点。
从命令式转向了声明式,使得我们可以专注于状态管理,减少了出现问题的概率; 少了很多割裂感,无需在各个 Java、Kotlin、XML 文件之间来回切换,不管是 UI 还是业务逻辑,都是直接 Kotlin 搞定 (但现阶段 Preview 功能感觉还是好慢); 由于 Android 各个版本之间的差异性,同一套 View 体系代码经常会在不同系统版本上有着不同的风格,导致我们经常需要定义各种 style 和 theme 来保证 UI 统一,采用 Jetpack Compose 后就没有这种烦恼了,由其来抹平各个系统版本的差异性。
长按右侧二维码
查看更多开发者精彩分享
"开发者说·DTalk" 面向